Skip to content

feat(datagrid): add column and cell-range selection#1450

Merged
datlechin merged 15 commits into
TableProApp:mainfrom
shouwang0527:zhaojy/feat-cell-selection
May 28, 2026
Merged

feat(datagrid): add column and cell-range selection#1450
datlechin merged 15 commits into
TableProApp:mainfrom
shouwang0527:zhaojy/feat-cell-selection

Conversation

@shouwang0527

@shouwang0527 shouwang0527 commented May 28, 2026

Copy link
Copy Markdown
Contributor

Closes #1446.

Summary

  • Cmd+click a column header selects the entire column
  • Click a cell then Shift+click another in the same column selects the vertical range
  • Cmd+click individual cells toggles non-contiguous cell selection
  • Cmd+C copies selected cell values (newline-separated)
  • Cell selection and row selection are mutually exclusive
  • Escape clears cell selection

Implementation

New CellSelection enum models three selection modes (column, range, cells) without materializing 10k+ positions for full-column select. Integrates with the existing TableSelection struct and reuses the batched reload mechanism.

Visual highlight uses a 12% accent-color tint on selected cells, similar to the existing modified-column tint pattern.

Files changed

  • CellSelection.swift (new) - selection model
  • TableSelection.swift - added cellSelection property
  • KeyHandlingTableView.swift - mouseDown routing, copy priority, Escape handling
  • SortableHeaderView.swift - Cmd+click dispatches to selectColumn
  • DataGridCellContent.swift - added isInCellSelection to cell state
  • DataGridCellView.swift - renders selection tint
  • DataGridView+Columns.swift - computes isInCellSelection per cell
  • DataGridView+RowActions.swift - selectColumn + copyCellSelection methods
  • DataGridView+Selection.swift - clears cell selection on row select

Local build skipped (no Xcode locally), relying on CI.

@github-actions

github-actions Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f6af85204f

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

}

private func handleShiftClickCell(row: Int, dataColumn: Int) -> Bool {
guard let anchor = cellSelectionAnchor, anchor.column == dataColumn else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the focused cell as the range anchor

When a user follows the advertised workflow of plain-clicking one cell and then Shift-clicking another in the same column, cellSelectionAnchor is still nil because plain clicks only update focusedRow/focusedColumn. This guard therefore takes the first Shift-click as the anchor and selects only that one cell, so the promised vertical range is not created unless the first cell was Cmd-clicked or Shift-clicked instead.

Useful? React with 👍 / 👎.

@datlechin

Copy link
Copy Markdown
Member

Code review

Solid model design (CellSelection with O(1) column-select, no materialization of 10k+ positions) and the row/cell selection mutex is wired in the right places. Below are the issues to address before merge.

Blockers

1. copyRowsAsTSV validation is wrong. validateUserInterfaceItem now enables copyRowsAsTSV(_:) whenever cellSelection is non-empty, but copyRowsAsTSV only handles selectedRowIndexes. With cell-only selection, the menu item is enabled but copies nothing.

case #selector(copy(_:)), #selector(copyRowsAsTSV(_:)):
    return !selection.cellSelection.isEmpty || !selectedRowIndexes.isEmpty

Either route copyRowsAsTSV through the cell-selection path too, or revert that case to !selectedRowIndexes.isEmpty.

2. Clipboard is plain text only, not TSV. Newline-joined plain text means a multi-cell paste into Excel/Numbers/SQL editors lands in one cell. The codebase already has a TSV writer in copyRowsAsTSV. Write both public.utf8-plain-text and public.tab-separated-values representations to NSPasteboard so it pastes correctly everywhere. This also makes cell-copy consistent with row-copy.

3. Cmd+Shift-click on a header cancels multi-sort.

// SortableHeaderView.swift:188
if modifiers.contains(.command) {
    coordinator.selectColumn(dataIndex)
    return
}

This matches .command even when .shift is held. The table-view path is explicit about this: modifiers.contains(.command) && !modifiers.contains(.shift). Mirror that here, otherwise Cmd+Shift+header-click (a plausible multi-sort attempt) is hijacked into column-select.

4. No tests. CellSelection is pure-Swift and trivially testable (contains, affectedRows, affectedColumns, isEmpty), and TableSelection.reloadIndexes has a new cell-selection branch worth covering. CLAUDE.md mandates tests for testable features; the PR description noting "Local build skipped (no Xcode locally), relying on CI" doesn't substitute. CI runs tests, it doesn't write them.

5. No docs update. New shortcuts (Cmd-click header, Shift-click cell, Cmd-click cell, Esc) need entries in docs/features/keyboard-shortcuts.mdx and the data-grid feature page. Mandatory per CLAUDE.md.

UX call worth confirming

Shift-click after a column selection collapses to a single cell. handleShiftClickCell requires cellSelectionAnchor != nil, but selectColumn sets the anchor to nil. So the flow Cmd+click header → Shift+click a cell in that column drops to a single-cell selection instead of extending into a row range. Either treat .column as an implicit anchor (e.g., row 0), or document this as not supported.

Lower-priority observations

  • CellSelection.swift is a model under Views/Results/. Consider Models/UI/CellSelection.swift to match the pattern used by PhpTreeNode.swift.
  • Silent skip on displayRow(at:) failure in copyCellRange / copyCellPositions — partial copy with no signal can produce wrong SQL in the "paste into WHERE clause" use case. At minimum add an OSLog warn.
  • CHANGELOG entry reads a bit long. Suggest: Column and cell-range selection in the data grid via Cmd-click header, Shift-click range, Cmd-click cells (#1446).
  • PR diverges from the issue's "click column header" wording (uses Cmd+click so plain click still sorts). Defensible — matches DataGrip/Postico/Excel — but worth calling out in the PR body so the divergence isn't lost.
  • The visual 12% accent tint stacks with the existing modified-column tint. Worth a manual look in the running app.
  • KeyHandlingTableView.swift grew ~86 lines. Check against SwiftLint's 1200-line warning; if it's close, extract the mouseDown routing into KeyHandlingTableView+CellSelection.swift.

Build / verification

PR test plan is unchecked. Code looks compile-safe, but the mouse-routing and Escape changes need a manual pass through the listed scenarios — especially the 10k+ row column-select non-freeze and the inline-edit-still-works case — before merging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@shouwang0527 shouwang0527 force-pushed the zhaojy/feat-cell-selection branch from f6af852 to 871cbf8 Compare May 28, 2026 05:27
- Separate copyRowsAsTSV validation from copy (only rows, not cells)
- Write TSV format to clipboard so paste into Excel/Numbers works
- Restrict Cmd+click column header to exclude Shift (Cmd+Shift = multi-sort)
- Set implicit anchor on column select so Shift+click can extend
- Add unit tests for CellSelection and TableSelection
- Update keyboard-shortcuts docs with new selection shortcuts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@shouwang0527

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review! All blockers and the UX suggestion are addressed in the latest push:

  1. copyRowsAsTSV validation - split into its own case, only checks selectedRowIndexes
  2. Clipboard TSV format - copyCellRange and copyCellPositions now use writeRows(tsv:html:) so paste into Excel/Numbers lands correctly
  3. Cmd+Shift header click - added && !modifiers.contains(.shift) so Cmd+Shift still triggers multi-sort
  4. Tests - added CellSelectionTests.swift covering contains, affectedRows, affectedColumns, isEmpty for all cases, plus TableSelection.reloadIndexes cell-selection branch
  5. Docs - updated docs/features/keyboard-shortcuts.mdx with the new selection shortcuts
  6. UX: Shift+click after column select - selectColumn now sets an implicit anchor at row 0 so Shift+click can extend into a range

Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
@datlechin

Copy link
Copy Markdown
Member

Code Review

Overview

Adds three new selection modes to the data grid (full column, vertical range, non-contiguous cells), wired through a new CellSelection enum stored on TableSelection. Selection state drives a 12% accent tint, Cmd+C copy, and Escape clear.

🔴 Critical: Won't compile

DataGridView+RowActions.swift calls ClipboardService.writeRows with the wrong signature.

The protocol (TablePro/Core/Services/Infrastructure/ClipboardService.swift:20) is:

func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload)

But this PR calls:

ClipboardService.shared.writeRows(tsv: lines.joined(separator: "\n"), html: nil)

Missing gridRows:. This will fail xcodebuild. Fix by either:

  • Switching to writeText(...) (matches copyColumnValues and is the right choice — a single column of values isn't a tabular grid), or
  • Providing a real GridRowsClipboardPayload so external paste round-trips work like row copy.

The writeText route is the smaller, more consistent change.

🟡 Correctness / behavior concerns

  • Stale cellSelectionAnchor after reload. The anchor lives on KeyHandlingTableView as a bare var, not in TableSelection. When the underlying dataset changes (new query, filter, sort), selection is reset but cellSelectionAnchor is not, so a stale (row, column) can drive the next shift+click. Either move the anchor into TableSelection so resets clear it together, or clear it whenever the rows change.

  • cancelOperation should still call super. It now only handles the cell-selection-clear case and silently returns otherwise. Forwarding to super.cancelOperation when there's nothing to clear is more correct (and matches the AppKit chain).

  • Header has no visual indication a column is selected. selectColumn tints cells but SortableHeaderView doesn't reflect the state. Users who scroll horizontally or focus elsewhere lose the cue. Worth painting a subtle tint behind the column header too.

  • Shift+click in a different column silently switches anchor. handleShiftClickCell replaces the selection with a single cell when the anchor column doesn't match. That matches the docs but a user who shift+clicked the wrong column loses their prior anchor with no feedback. Consider treating cross-column shift+click as a no-op so the original anchor survives.

  • affectedRows empty for .column is load-bearing. TableSelection.reloadIndexes returns nil for column-only transitions, and the actual reload is driven by the side-channel reloadVisibleColumnCells in selection.didSet. It works, but the two-path design is fragile — columnChangeReturnsNilDueToEmptyRows enshrines the quirk in a test rather than fixing it. Consider making affectedRows return all rows for .column and removing the side-channel.

🟢 Style / convention nits

  • Hardcoded 0.12 accent alpha at DataGridCellView.swift:211. The file already uses modifiedColumnTint resolved from the theme palette. Add a cellSelectionTint to the palette so light/dark and theme switching stay consistent.

  • handleCmdClickCell switch is redundant. Lines 167–172 collapse .column, .range, and .none to the same empty Set(). Simplify:

    var positions: Set<CellPosition> = {
        if case .cells(let existing) = selection.cellSelection { return existing }
        return []
    }()
  • Duplicated copy plumbing. copyCellRange, copyCellPositions, and the existing copyColumnValues share the same "look up column type → format cell → join with \n" loop. After fixing the API call, extract a small helper that takes [(row: Int, column: Int)] and returns the joined string.

  • Tests cover the model, not the interactive layer. CellSelectionTests is solid for the enum. There are no tests for copyCellSelection formatting (especially multi-row/multi-column .cells, which currently flattens rather than producing a 2D grid). Worth adding before this ships.

Other notes

  • CHANGELOG, docs (keyboard-shortcuts.mdx), and conventional commit scope (datagrid) all look right.
  • No localization concerns.
  • Cmd+click on column header doesn't conflict with existing header shortcuts (Shift+click is multi-sort, plain click cycles sort).

Recommendation

Request changes. The writeRows call is a build-breaker — fix that first. Anchor lifetime and the theme-vs-hardcoded tint are worth addressing in the same pass.

datlechin added 12 commits May 28, 2026 20:21
Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
NSToolbar.Identifier is a String typealias; calling .rawValue on it does not compile.
Fix the writeRows compile error by switching to writeText; the cell-copy paths produce a single column of values, not a tabular grid. Extract the per-position copy formatting into a shared helper.

Move cellSelectionAnchor into TableSelection so resetting the selection also clears the anchor. Cancel-operation now falls through to super when there is no cell selection to clear. Shift+click in a different column is now a no-op so the original anchor survives.

Route the cell-selection tint through DataGridCellPalette instead of hardcoding the alpha in draw. Mirror the same accent tint behind selected column headers via SortableHeaderCell so the selection state is visible.
Replace the CellSelection enum with a GridSelection value type that stores a list of GridRect rectangles plus an active cell and anchor. A row, a column, a single cell, and a multi-rectangle selection are all the same shape.

Move selection rendering off the cells. Cells no longer carry isInCellSelection. DataGridRowView draws the selection fill for cells in its row using NSColor.selectedContentBackgroundColor, and a single GridSelectionOverlay view sits on top of the table painting the rectangle borders. No more hardcoded accent alphas, vibrancy and dark mode are picked up from the system.

Mouse handling moves into GridSelectionController. Click and drag now creates a rectangular selection. Shift+click extends from the anchor across any column. Cmd+click toggles cells. Cmd+A selects the whole grid. Shift+Arrow extends the active cell, Cmd+Shift+Arrow jumps to the grid edge. Escape clears the selection and falls through to super otherwise.

Cmd+C copies the bounding rectangle as TSV with tabs between columns and newlines between rows, blanks for gaps in non-contiguous selections, so a paste into Numbers or Excel preserves the shape.

Drop the old reloadVisibleColumnCells side channel and the cellSelection/anchor fields on TableSelection. Clear the selection on applyFullReplace and releaseData so it cannot drift onto unrelated rows after a query reload.

Also includes the one-line NSToolbar.Identifier test fix that was blocking the test target build on main.
Clear the selection on any structural reload (sort, filter, query change) by hooking applyStructuralUpdate so display-indexed coordinates can never refer to a different record after the table reorders.

Cmd+drag now appends a fresh rectangle to the existing selection, matching Numbers and Excel additive selection. The drag-tracking loop reports back whether the pointer actually moved so plain cmd+click still toggles a single cell and cmd+drag builds a new rectangle.

Right-click inside an existing cell selection preserves the selection instead of collapsing it to a single-row selection, so context-menu actions can act on the rectangle the user already drew.

Draw a 2pt accent-color border around the active cell within multi-cell selections so the keyboard or paste target is distinguishable from the rest of the highlight, the same affordance Numbers uses.

When VoiceOver is enabled, post an accessibility announcement on every selection change ("5 cells selected, rows 2 to 6, columns 1 to 1" or "Cell selection cleared") so assistive tech users get an audible summary.
…selection

A plain click no longer creates a 1x1 rectangle in the GridSelection. The previous behaviour painted a perimeter border and an accent-tinted column header on every click, on top of the existing focus ring, so a focused cell looked over-decorated.

beginDrag with no modifiers now just arms the drag origin and clears any prior selection; the rectangle only materialises after the pointer actually moves. Cmd+click and Shift+click are unchanged.

Double-click now reaches NSTableView's doubleAction. The previous mouseDown swallowed the event without calling super for clickCount >= 2, so handleDoubleClick (the inline-edit entry point) never fired. Click counts >= 2 now forward to super.mouseDown before any selection logic runs.

Header column highlight is only drawn for rectangles whose row range covers every row in the table. A single-cell or partial-range selection no longer tints the column header.
Selection rendering was inverted: a 1.5pt 100% accent border was the dominant feature while the header and body fills were a barely visible 18% accent. The pattern in NSTableView row selection, Numbers, and Excel is the opposite, fill carries the meaning and borders are subtle or absent.

Header now fills with NSColor.selectedContentBackgroundColor at full opacity when its column is selected, with text and sort indicator switched to alternateSelectedControlTextColor for contrast. This makes the header the strong anchor of a column selection, the way Numbers does it.

Body fill bumped from 0.18 to 0.28 alpha so the selection reads as deliberate without competing with the row striping.

Perimeter border now skips full-height rectangles (column selections, full-grid selectAll) because the header and fill already communicate the selection there. For arbitrary rectangles produced by drag and shift+drag, the border is now 1pt at 70% of the fill hue instead of 1.5pt at 100% accent, so it relates visually to the fill instead of overpowering it.

Active-cell border is unchanged at 2pt controlAccentColor, only drawn when the selection spans more than one cell.
… active

The inline editor sits on top of a cell that still draws its own exterior focus ring and, when the row is emphasized, its manual focus border. The editor adds another 2pt keyboardFocusIndicatorColor border on its container, so the user sees two stacked outlines on the same cell in edit mode.

DataGridCellView now exposes applyOverlayActive so it can hide the focus ring, the focus-ring mask and the manual drawFocusBorder while an overlay is on top. CellOverlayBase.install and removeOverlay call into this so the cell goes quiet when the editor or viewer mounts and lights up again when it dismisses, leaving the editor border as the only focus signal during editing.
The previous fix removed the underlying cell focus indicators but the inline editor's NSTextView still drew its own focus ring when it became first responder, leaving a thin inner outline inside the editor container. Set textView.focusRingType = .none so only the container's keyboardFocusIndicatorColor border remains as the edit-mode signal.
…ditor

When a cell with an active GridSelection rectangle goes into edit mode, the GridSelectionOverlay kept drawing the rectangle border underneath the editor's 2pt focus border, so the user saw two outlines on the same cell.

GridSelectionOverlay now asks the coordinator which cell currently hosts a CellOverlayEditor or CellOverlayViewer and skips drawing the perimeter border for any rectangle that contains that cell, while keeping disjoint rectangles bordered. The active-cell accent border is also suppressed for the edited cell. Selection fills on the row view stay so the user still sees what is selected.

CellOverlayBase.install and removeOverlay mark the selection overlay for redraw so the border appears or disappears together with the editor.
…shift+click

Cmd+click on a data cell used to call selectRowIndexes with byExtendingSelection: true so the row selection grew alongside the cell selection. The tableViewSelectionDidChange delegate then saw the row selection grow while the cell selection was still non-empty and cleared the cell selection mid-gesture; endDrag then rebuilt the cell selection from dragBaseSelection, leaving both row and cell selection simultaneously active.

selectRowIndexes is now always called with byExtendingSelection: false from the cell-selection path. Multi-row selection still works via the row-number column where super.mouseDown dispatches NSTableView's native handling. The intermediate row-change is also wrapped in isSyncingSelection so the delegate skips its own clear pass.

Reset hasOverlay in DataGridCellView.configure so a recycled cell view never carries a stale overlay state forward.

Also fixes two non-code issues flagged in review: duplicate Added entries in CHANGELOG (leftover from the earlier 'tighten unreleased entries' commit) and missing tests for selectEntireColumn, selectEntireRow, extendActiveCell and its jump-to-edge / empty-selection paths.
@datlechin datlechin merged commit 898e0e6 into TableProApp:main May 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(datagrid): support column and cell-range selection

2 participants